Skip to content

feat(xpp/http): httptest — three-layer HTTP testing (tower/axum + wiremock aligned) - #87

Merged
mivinci merged 2 commits into
mainfrom
codebuddy/httptest
Aug 26, 2026
Merged

mivinci merged 2 commits into
mainfrom
codebuddy/httptest

Conversation

@mivinci

@mivinci mivinci commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the 540-line hand-rolled test_server.h with a three-layer testing model aligned with the Rust ecosystem (tower/axum + wiremock — the design discussion is in the deleted todos/httptest.md, now implemented).

Three layers

Layer What Status
0 router(req) direct call — handler/router/middleware unit tests (in-process, no sockets) ✅ already existed (PR #85's Router-as-Handler)
1 test::Server — real TCP fixture for client tests & end-to-end this PR
2 test::EvilServer — fault injection (truncated Content-Length) this PR

test::Server (http/test/server.h)

// Handler-driven (any behavior — the wiremock "arbitrary" form):
test::Server ts([](Request req) { return Response::ok("hello"); });
auto r = client.get(ts.url().c_str()).await();

// Multi-route (Router capabilities: :params, layers, async handlers):
test::Server routed([](ServerBuilder &b) {
  b.route("GET /users/:id", [](Request req, String id) { return Response::ok(id); });
});

// Data-driven (preset spec):
TestResponseSpec spec;
spec.body = Bytes::from("hello");
test::Server ts2(spec);

Three constructors dispatched by first-arg type (first_arg trait). Construct = start (bind 127.0.0.1:0 + listen sync — port() valid immediately). RAII dtor = stop + drain. Bind failure asserts (Go httptest.NewServer panic semantics).

The spec compiles into Router routes — no hand-rolled HTTP anywhere. All seven fields work: static/echo-body/echo-method/redirect/delay/mid-body-stall/headers. (Mid-body stall = channel body with delayed producer: pausing production pauses transmission.)

test::EvilServer (http/test/evil_server.h)

Raw TCP (~100 lines): declares full Content-Length, sends partial body, hard-closes — the mid-body-disconnect fault that a well-formed server cannot produce (wiremock's Fault::Abort family). Migrated from the old spec's truncate_body_after.

Per-file tests (the "each file gets a test" convention)

  • http/test/server_test.cpp — 7 tests: handler ctor, spec ctor, redirect, echo body, echo method, builder-configure (:param injection), delay
  • http/test/evil_server_test.cpp — 1 test: declared 1MB / sent 64KB truncation surfaces as a body-read error

Migration

  • client_test.cpp (13 tests) + http_convenience_test.cpp (2 tests): old TestServer → test::Server; truncation test → test::EvilServer
  • Old http/test_server.h deleted (−540 lines of hand-rolled HTTP parsing, response building, reason-phrase table, Connection: close management)

Bug found & fixed during migration

The spec's echo_request_method didn't work alongside echo_request_body — the method was consumed with the Request before it could be captured. Fixed: capture the method before req.into_body().

Verification

  • ASan full suite: 83/83 (69 existing zero-breakage + 14 new)
  • All existing client/convenience tests pass unchanged against the new fixture
  • clang-format clean

…emock aligned)

Replaces the 540-line hand-rolled test_server.h with a three-layer
testing model aligned with the Rust ecosystem (todos/httptest.md, done):

  Layer 0  router(req) direct call          — already exists (PR #85);
           in-process, no sockets — handler/router/middleware unit tests
  Layer 1  test::Server (test/server.h)     — THIS: real TCP fixture for
           client tests & end-to-end
  Layer 2  test::EvilServer (test/evil_server.h) — fault injection
           (truncated Content-Length — what a well-formed server can't do)

test::Server (libxpp/xpp/http/test/server.h):
- Three constructors dispatched by first-arg type (first_arg trait):
  builder-configurator (multi-route), single handler (Router fallback),
  and TestResponseSpec (data-driven: echo/redirect/delay/mid-body-stall)
- Construct = start (bind 127.0.0.1:0 + listen sync, port valid
  immediately); RAII dtor = stop + drain; bind failure asserts
  (mirrors Go httptest.NewServer panic)
- url() for ready-to-use client URLs
- Spec compiles into Router routes — no hand-rolled HTTP anywhere

test::EvilServer (libxpp/xpp/http/test/evil_server.h):
- Raw TCP (~100 lines): declares full Content-Length, sends partial
  body, hard-closes — the mid-body-disconnect fault injection that
  test::Server cannot produce

Per-file tests (the 'each file gets a test' convention):
- test/server_test.cpp — 7 tests: handler ctor, spec ctor, redirect,
  echo body, echo method, builder-configure (:param injection), delay
- test/evil_server_test.cpp — 1 test: declared 1MB / sent 64KB
  truncation surfaces as a body-read error

Migration: client_test.cpp + http_convenience_test.cpp now use the new
fixtures (test::Server + test::EvilServer); old http/test_server.h
deleted (−540 lines of hand-rolled HTTP parsing/response building).

The spec's echo_request_method now works alongside echo_request_body
(method captured before the Request is consumed by into_body()).

Verified: ASan full suite 83/83 (69 existing + 14 new tests).
@mivinci
mivinci force-pushed the codebuddy/httptest branch from b2cc622 to 9ddc4a1 Compare August 26, 2026 03:23
The response streaming path (stream_channel_body) held a raw xHttpCtx*
across suspension points, protected only by ServerLifetime, which covers
server teardown but not connection teardown. On client disconnect the
event loop frees the stream (and the xHttpCtx embedded in it) via
xHttpConnClose — a path that never reaches on_done — so the parked
streaming task wrote the freed ctx, a heap-use-after-free.

Add an on_close callback to xHttpRouteInfo, invoked by xHttpConnClose
before the stream is freed, plus a ConnLifetime (Arc<bool closed>) keyed
by ctx address that the streaming task checks before touching ctx.
@mivinci
mivinci merged commit a965b4b into main Aug 26, 2026
28 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant